Skip to content

Migrate HTTP layer to utopia-php/client#134

Open
loks0n wants to merge 1 commit into
mainfrom
feat/migrate-to-client
Open

Migrate HTTP layer to utopia-php/client#134
loks0n wants to merge 1 commit into
mainfrom
feat/migrate-to-client

Conversation

@loks0n

@loks0n loks0n commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the hand-rolled cURL in Adapter::request() / Adapter::requestMulti() with the utopia-php/client PSR-18 client. Every adapter now consumes the client's PSR-7 response directly (getStatusCode() / getBody() / getHeaderLine()) instead of the old custom ['statusCode', 'response', 'headers', 'error'] array.

Changes

Base Adapter

  • request() builds a PSR-7 request via Utopia\Psr7\Request\Factory (picks json / form / multipart from the Content-Type) and returns Psr\Http\Message\ResponseInterface.
  • requestMulti() sends sequentially over a single HTTP/2, connection-reused client (APNs requires HTTP/2) and returns ResponseInterface[] in request order — callers map results by position instead of ['index']/basename(['url']).
  • Transport failures now surface as ClientExceptionInterface rather than an error field.

Adapters (all 22)

  • SMS / Chat / Email / Push updated to read the PSR-7 response.
  • Mailgun attachments switched from curl_file_create to Psr7\Request\Multipart\Part::file.
  • Fixed a latent Vonage bug: an associative 'Content-Type' => … header would have sent the body as multipart instead of form-urlencoded.

⚠️ Breaking: minimum PHP is now 8.4

utopia-php/client requires PHP 8.4, so this bumps:

  • composer.json require.php (>=8.1>=8.4) and config.platform.php (8.38.4)
  • Dockerfile (php:8.3.11php:8.4)
  • phpstan/phpstan (^1^2, needed to parse the client's PHP 8.4 syntax)

Also fixes a PHP 8.4 implicit-nullable deprecation in Helpers/JWT.php.

Testing

  • composer lint ✅ · composer analyse (PHPStan v2, level 6) ✅
  • SESRoutingTest + ResendRoutingTest — 26 network-free routing tests, 113 assertions ✅ (stubs return Utopia\Psr7\Response)
  • SMSTest (Mock → request-catcher, Docker on PHP 8.4) ✅ — verifies User-Agent, custom headers, POST, JSON body, response parsing
  • requestMulti smoke test ✅ — 1 URL + 3 bodies → 3 ordered Utopia\Psr7\Response, all 200

🤖 Generated with Claude Code

Replace the hand-rolled cURL in Adapter::request()/requestMulti() with the
utopia-php/client PSR-18 client, and have every adapter consume the PSR-7
response directly (getStatusCode/getBody/getHeaderLine) instead of the old
custom result array.

- request() builds a PSR-7 request via the request factory (json/form/
  multipart chosen from Content-Type) and returns ResponseInterface.
- requestMulti() sends sequentially over one HTTP/2, connection-reused
  client (APNs requires HTTP/2) and returns responses in request order;
  callers map results by position.
- Mailgun attachments now use Psr7 multipart Part::file instead of
  curl_file_create; fixed a latent Vonage associative-header bug.
- Test stubs (SESStub/ResendStub) return Utopia\Psr7\Response.

utopia-php/client requires PHP 8.4, so bump composer (require + platform),
the Dockerfile to php:8.4, and phpstan to ^2 (needed to parse the client's
8.4 syntax). Also fix a PHP 8.4 implicit-nullable deprecation in JWT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown

Greptile Summary

Replaces the hand-rolled cURL layer across all 22 adapters with utopia-php/client, returning PSR-7 ResponseInterface from request() / requestMulti() and removing the old ['statusCode', 'response', 'headers', 'error'] array contract. PHP minimum is bumped to 8.4 and the Vonage latent Content-Type bug is fixed as a bonus.

  • Adapter.php: buildRequest() cleanly maps legacy Key: value header strings to PSR-7 requests, selecting JSON / form / multipart encoding from the Content-Type header; requestMulti() reuses a single HTTP/2 connection but is now sequential rather than concurrent.
  • Email adapters (SES, Resend, Mailgun, Sendgrid): error-handling paths introduced a repeated pattern of reading $result->getBody() twice — once for json_decode and again for a raw-body fallback — which silently returns an empty string with any non-seekable PSR-7 stream.
  • Push adapters (APNS, FCM): correctly switched from basename($result['url']) / $result['index'] to positional indexing; FCM lacks an explicit guard if the Google token request itself fails.

Confidence Score: 3/5

The core HTTP migration is correct and well-structured, but the error-reporting paths in four email adapters have a systematic double-read of the response body stream that will silently drop real error text with any non-seekable stream.

The double-read pattern appears in four separate adapters and within SES's errorMessage helper itself. In each case the raw-body fallback will return an empty string if the stream was consumed by the preceding json_decode call, causing callers to receive 'Unknown error' instead of the actual provider message. This is a fresh regression introduced by the move from pre-decoded response arrays to streaming bodies.

SES::errorMessage / isTemplateMissing, Resend::extractErrorMessage, Mailgun error branch, and Sendgrid error branch. The FCM access-token fetch also warrants a status-code guard.

Important Files Changed

Filename Overview
src/Utopia/Messaging/Adapter.php Core HTTP layer replaced: hand-rolled cURL removed in favour of utopia-php/client; request() now returns PSR-7 ResponseInterface and requestMulti() sends sequentially over a connection-reused HTTP/2 client. Logic is correct; performance of requestMulti is now sequential.
src/Utopia/Messaging/Adapter/Email/SES.php errorMessage reads the body stream twice in the same call, and isTemplateMissing reads it up to three times across helper calls — the raw-body fallback will silently return empty string with any non-seekable stream.
src/Utopia/Messaging/Adapter/Email/Resend.php extractErrorMessage reads the body stream twice; the elseif raw-body branch will always see an empty string with a non-seekable stream.
src/Utopia/Messaging/Adapter/Email/Mailgun.php Attachment handling correctly migrated from curl_file_create to Part::file. Error branch reads the body stream twice; raw fallback may return empty string with non-seekable streams.
src/Utopia/Messaging/Adapter/Email/Sendgrid.php Status code check updated to PSR-7; error branch reads body stream twice, making the raw-body fallback unreachable with non-seekable streams.
src/Utopia/Messaging/Adapter/Push/APNS.php Device token lookup correctly switched from basename($result['url']) to positional index into $message->getTo(), which is reliable since requestMulti preserves request order.
src/Utopia/Messaging/Adapter/Push/FCM.php OAuth token fetch and FCM multi-send correctly ported to PSR-7. $accessToken can silently be null if the token endpoint returns a non-standard error response.
src/Utopia/Messaging/Adapter/SMS/Vonage.php Fixes a latent bug: the old associative header format was silently dropped; the corrected Key: value string format is now picked up by buildRequest.
Dockerfile PHP bumped to 8.4, but the Alpine tag is now floating where the previous image pinned both the PHP patch and Alpine versions.
composer.json Adds utopia-php/client ^0.1.3, bumps PHP minimum to >=8.4 and PHPStan to ^2.0. All changes are consistent with the stated intent and composer.lock is updated.

Comments Outside Diff (4)

  1. src/Utopia/Messaging/Adapter/Email/SES.php, line 663-682 (link)

    P1 errorMessage reads the response body stream twice — once to JSON-decode it and again for the raw fallback. With any non-seekable PSR-7 stream (e.g. a streaming HTTP/2 response from a future client), the second read returns an empty string, so the function falls through to 'Unknown error' instead of returning the actual API error text. isTemplateMissing compounds this by calling both errorType() and errorMessage() before reading the body a third time for $entryResults — all three consume the same stream. Save the raw string first and reuse it.

  2. src/Utopia/Messaging/Adapter/Email/Resend.php, line 243-260 (link)

    P1 Same double-read pattern: $result->getBody() is cast to string once for json_decode, then cast again in the elseif fallback. If the stream is consumed by the first read, the elseif branch always sees an empty string and the raw error text is never returned. Save the raw string upfront.

  3. src/Utopia/Messaging/Adapter/Email/Mailgun.php, line 156-167 (link)

    P1 Same issue: $result->getBody() is cast to string for json_decode on line 157, then cast again on lines 161–162 for the raw-body fallback. The second cast returns an empty string if the stream position is not rewound, so callers never see the actual Mailgun error text.

  4. src/Utopia/Messaging/Adapter/Email/Sendgrid.php, line 146-157 (link)

    P1 Same double-read pattern in the error branch: the stream is cast to string for json_decode and then cast again for the raw-body fallback. Save the string first so both paths use the same value.

Reviews (1): Last reviewed commit: "Migrate HTTP layer to utopia-php/client" | Re-trigger Greptile

);

$accessToken = $token['response']['access_token'];
$accessToken = \json_decode((string) $token->getBody(), true)['access_token'] ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No guard on failed token response

If Google returns a 4xx/5xx to the JWT exchange (e.g. an invalid private key or expired credential), json_decode on the error body won't contain access_token, so $accessToken silently becomes null. The string interpolation "Authorization: Bearer {$accessToken}" then sends Bearer (empty token) to every FCM push, which all fail with a 401. Adding an explicit status check before consuming $accessToken would surface the credential error immediately rather than generating hundreds of silent push failures.

Comment on lines +202 to 207
$client = $this->client($timeout, $connectTimeout, multi: true);

\curl_setopt($ch, CURLOPT_URL, $urls[$i]);
\curl_setopt($ch, CURLOPT_POSTFIELDS, $bodies[$i]);
\curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
\curl_setopt($ch, CURLOPT_PRIVATE, $i);
\curl_multi_add_handle($mh, \curl_copy_handle($ch));
$responses = [];
foreach ($urls as $i => $url) {
$responses[] = $client->sendRequest($this->buildRequest($method, $url, $headers, $bodies[$i]));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 requestMulti is now sequential, not concurrent

The old curl_multi_exec implementation dispatched all requests at the same time and collected results as they completed. The new loop calls $client->sendRequest() synchronously for each URL, awaiting each response before sending the next. For APNS and FCM batches that can reach 5,000 recipients, throughput under load will be noticeably lower than before, even with HTTP/2 connection reuse eliminating per-request TLS handshakes.

Comment thread Dockerfile
--prefer-dist

FROM php:8.3.11-cli-alpine3.20
FROM php:8.4-cli-alpine

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The image tag was previously pinned to a specific Alpine patch release (php:8.3.11-cli-alpine3.20). The new tag php:8.4-cli-alpine is a floating tag: the next docker pull will silently pick up a new Alpine minor version, which can introduce different library versions or behaviour and break reproducible builds.

Suggested change
FROM php:8.4-cli-alpine
FROM php:8.4-cli-alpine3.21

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant